<?php

/**
 * @doc
 *   关于钉钉机器人文档，请参考 https://developers.dingtalk.com/document/robots/custom-robot-access#topic-2026027
 *   关于如何使用，请参考 https://git.i-sanger.com/a/ypt_api/blob/master/doc/dingRobot.md
 *
 * tip：每个钉钉群机器人每分钟最多发送20条消息到群里，如果超过20条，会限流10分钟
 */

namespace App\Common\Tools;

use App\Common\Enums\Code;
use App\Exceptions\ApiException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;

class DingTalk
{
    // 钉钉机器人接口地址
    const URL_ROBOT_WEBHOOK = 'https://oapi.dingtalk.com/robot/send';

    protected string $robot;
    // 使用哪个机器人发送消息
    protected string $token;
    // 对应机器人的sign
    protected string $secret;
    // 根据指定的机器人生成发送的链接
    protected string $url;

    // 钉钉机器人支持的消息类型 文本
    const MSG_TYPE_TEXT = 'text';
    // 钉钉机器人支持的消息类型 markdown
    const MSG_TYPE_MARKDOWN = 'markdown';
    // 钉钉机器人支持的消息类型 链接
    const MSG_TYPE_LINK = 'link';
    // 钉钉机器人支持的消息类型 actionCard
    const MSG_TYPE_ACTION_CARD = 'actionCard';
    // 钉钉机器人支持的消息类型 feedCard
    const MSG_TYPE_FEED_CARD = 'feedCard';
    private string $prefix = '';
    private int    $second;
    private int    $num;
    private bool   $enabled;  // 是否可以发送
    private int    $timeout;  // 超时时间

    /**
     * 单例
     *
     * @return self
     */
    public static function getInstance()
    {
        if (!app()->has(static::class)) {
            app()->singleton(static::class);
        }

        return app()->get(static::class);
    }

    /**
     * 加签
     *
     * @param string $secret
     *
     * @return array
     */
    private function sign(string $secret = ''): array
    {
        [$s1, $s2] = explode(' ', microtime());
        $timestamp = (float)sprintf('%.0f', (floatval($s1) + floatval($s2)) * 1000);
        $data      = $timestamp . "\n" . $secret;
        $signStr   = base64_encode(hash_hmac('sha256', $data, $secret, true));
        $signStr   = utf8_encode(urlencode($signStr));
        return ['timestamp' => $timestamp, 'sign_str' => $signStr];
    }

    /**
     * 指定消息发送到哪个群
     * @param string $robot 指定要发送的机器人
     * @return DingTalk
     * @throws \Throwable
     */
    public function to(string $robot = 'default'): DingTalk
    {
        $config = config('ding.' . $robot);
        $token  = $config['token'] ?? '';
        $secret = $config['secret'] ?? '';

        // 没有获取到配置，报错退出
        throw_if(empty($token) || empty($secret), new ApiException(Code::NO_ROBOT_TOKEN_OR_SIGN, m(Code::NO_ROBOT_TOKEN_OR_SIGN)));

        $this->secret  = $secret;
        $this->token   = $token;
        $this->robot   = $robot;
        $this->timeout = $config['timeout'] ?? 3;
        $this->second  = $config['limit']['second'] ?? 60;
        $this->num     = $config['limit']['number'] ?? 19; // 不要超过20，否则会被限流。。。
        $this->enabled = $config['enabled'] ?? false;
        return $this;
    }

    /**
     * 生成发送消息所需要的链接。
     */
    protected function buildUrl(): void
    {
        $sign      = $this->sign($this->secret);
        $this->url = sprintf('%s?access_token=%s&timestamp=%s&sign=%s',
            static::URL_ROBOT_WEBHOOK,
            $this->token,
            $sign['timestamp'],
            $sign['sign_str']);
    }

    /**
     * 发送text类型消息
     * @param string $content 消息内容
     * @param array  $options 指定要at的人
     *                        [
     *                        'atMobiles':[],
     *                        'atUserIds':[],
     *                        'isAtAll':true|false,
     *                        ]
     * @return bool 发送是否成功
     * @throws \Exception
     */
    public function text(string $content, array $options = []): bool
    {
        $data = [
            'msgtype' => static::MSG_TYPE_TEXT,
            'text'    => [
                'content' => $content,
            ],
        ];

        if ($options) {
            $data['at'] = $options;
        }
        return $this->doSend($data);
    }

    /**
     * 发送markdown格式的消息
     * @param string $title   首屏会话透出的展示内容
     * @param string $content markdown格式的消息
     * @param array  $options 被@人的信息，格式参考text方法
     * @return bool 是否发送成功
     * @throws \Exception
     */
    public function markdown(string $title, string $content, array $options = []): bool
    {
        $data = [
            'msgtype'  => static::MSG_TYPE_MARKDOWN,
            'markdown' => [
                'title' => $title,
                'text'  => $content,
            ],
        ];
        if ($options) {
            $data['at'] = $options;
        }

        return $this->doSend($data);
    }

    /**
     * 发送link类型的消息
     * @param string $title      消息标题
     * @param string $text       消息内容。如果太长只会部分展示
     * @param string $messageUrl 点击消息跳转的URL
     * @param string $picUrl     图片URL
     * @return bool 发送是否成功
     * @throws \Exception
     */
    public function link(string $title, string $text, string $messageUrl, string $picUrl = ''): bool
    {
        $data = [
            'msgtype' => static::MSG_TYPE_LINK,
            'link'    => [
                'text'       => $text,
                'title'      => $title,
                'picUrl'     => $picUrl,
                'messageUrl' => $messageUrl,
            ],
        ];
        return $this->doSend($data);
    }

    /**
     * 整体跳转的actionCard
     * @param string $title       首屏会话透出的展示内容
     * @param string $text        markdown格式的消息
     * @param string $singleTitle 单个按钮的标题
     * @param string $singleURL   点击消息跳转的URL
     * @param int    $btnOrientation
     *                            0：按钮竖直排列
     *                            1：按钮横向排列
     * @return bool 发送是否成功
     * @throws \Exception
     */
    public function actionCardWhole(string $title, string $text, string $singleTitle, string $singleURL, int $btnOrientation = 0): bool
    {
        $data = [
            'msgtype'    => static::MSG_TYPE_ACTION_CARD,
            'actionCard' => [
                'title'          => $title,
                'text'           => $text,
                'singleTitle'    => $singleTitle,
                'singleURL'      => $singleURL,
                'btnOrientation' => $btnOrientation,
            ],
        ];
        return $this->doSend($data);
    }

    /**
     * 独立跳转的actionCard
     * @param string $title 首屏会话透出的展示内容
     * @param string $text  markdown格式的消息。
     * @param array  $btns  按钮信息
     *                      [
     *                      [
     *                      'title' => '按钮标题',
     *                      'actionURL' => '点击按钮触发的URL',
     *                      ]
     *                      ]
     * @param int    $btnOrientation
     *                      0：按钮竖直排列
     *                      1：按钮横向排列
     * @return bool 发送结果
     * @throws \Exception
     */
    public function actionCardPartial(string $title, string $text, array $btns, int $btnOrientation = 0)
    {
        $data = [
            'msgtype'    => static::MSG_TYPE_ACTION_CARD,
            'actionCard' => [
                'title'          => $title,
                'text'           => $text,
                'btnOrientation' => $btnOrientation,
                'btns'           => $btns,
            ],
        ];
        return $this->doSend($data);
    }

    /**
     * 发送feedCard类型消息
     * @param array $links 要发送的feedCard，格式如下：
     *
     * [
     *   [
     *     'title' =>'', // 单条信息文本
     *     'messageURL' =>'', // 点击单条信息到跳转链接
     *     'picURL' =>'', // 单条信息后面图片的URL
     *   ]
     * ]
     *
     * @return bool
     * @throws \Exception
     */
    public function feedCard(array $links): bool
    {
        $data = [
            'msgtype'  => static::MSG_TYPE_FEED_CARD,
            'feedCard' => [
                'links' => $links,
            ],
        ];
        return $this->doSend($data);
    }

    /**
     * 设置频率限制
     *
     * 设置频率限制用到了Redis
     *
     * @param string $prefix 业务前缀
     * @param int    $second
     * @param int    $num
     * @return DingTalk
     */
    public function freqLimit(int $second = 60, int $num = 20, string $prefix = '')
    {
        $this->prefix = $prefix;
        $this->second = $second;
        $this->num    = $num;
        return $this;
    }

    /**
     * 检测频率限制
     * @return bool
     */
    public function checkFreqLimit(): bool
    {
        // 使用redis加锁
        $curBlock = $this->currentTimeBlockByDay(intval($this->second));
        $prefix   = $this->prefix ?: $this->robot;
        $ck       = sprintf('%s:%s', $prefix, $curBlock);
        $sendNun  = Redis::incr($ck);

        // 发送次数超限，返回false
        if ($sendNun > $this->num) {
            return false;
        }
        // Redis 过期时间
        if ($sendNun == 1) {
            Redis::expire($ck, $this->second + mt_rand(10, 100));
        }
        return true;
    }

    /**
     * 发送给钉钉
     * @param array $data
     * @return bool
     *   true 发送成功
     *   false 发送失败
     */
    protected function doSend(array $data): bool
    {
        // 开关限制
        if (!$this->enabled) {
            Log::info('机器人没有开启');
            return false;
        }
        // 超频，不发送
        if (!$this->checkFreqLimit()) {
            Log::info('钉钉机器人超出频率限制', ['data' => $data]);
            return false;
        }

        $this->buildUrl();

        try {
            $sendRs = Http::asJson()->retry(3, 100)->timeout($this->timeout)->post($this->url, $data)->throw()->json();
            if ($sendRs['errcode'] > 0) {
                // 钉钉发送失败，记录日志
                throw new ApiException(Code::ROBOT_SEND_MSG_FAIL,
                    sprintf('钉钉机器人%s发送失败，错误信息：%s，错误码：%s', $this->robot, $sendRs['errmsg'], $sendRs['errcode']));
            }
        } catch (RequestException $e) {
            Log::channel('dingRobot')->warning($e->getMessage());
            return false;
        }

        return true;
    }

    /**
     * 把一天按照 seconds 切分成 n 段，并计算出当前时间所在的时间段
     *
     * 超过1天，请自行封装方法。。。
     *
     * @param int $seconds
     * @return int
     */
    protected function currentTimeBlockByDay(int $seconds): int
    {
        $secondsPast = intval(date('H')) * 3600 + intval(date('i')) * 60 + intval(date('s'));
        return floor($secondsPast / $seconds);
    }

}
